Repository navigation
Expose event release lifecycle metadata - #688
alisawavezen12 wants to merge 6 commits into
Conversation
Include unchecked releases without events and ignore empty or placeholder release names.
neSpecc
left a comment
There was a problem hiding this comment.
A few notes on the release metadata. The most important one is the unguarded event.payload access in _composeEventWithRepetition; the rest are about consistency of release names and the cost/semantics of latestRelease.
| */ | ||
| private readonly collection: Collection<ReleaseDBScheme>; | ||
| private readonly filesCollection: Collection<ReleaseFileDBScheme>; | ||
| private readonly latestRegisteredReleaseByProject = new Map<string, Promise<string | null>>(); |
| release: 1, | ||
| }, | ||
| }).then(doc => normalizeReleaseName(doc?.release)); | ||
| this.latestRegisteredReleaseByProject.set(key, latest); |
There was a problem hiding this comment.
We have 2 kind of factory calls:
- on request call (factory will be cleared after request along with this "cache")
- sharedFactories (used for SSH and GitHub) — it that case this cache would be never cleared.
Consider using DataLoader with case: true option. Like we have near planById.
| /** | ||
| * Returns the original release, preserving null for repetitions of legacy events. | ||
| * | ||
| * @param {Object} event - original event or composed repetition |
There was a problem hiding this comment.
jsdoc params are not fit actual params (firstSeenInRelease, payload)
| }, | ||
| Event: { | ||
| /** | ||
| * Returns the original release, preserving null for repetitions of legacy events. |
There was a problem hiding this comment.
| * Returns the original release, preserving null for repetitions of legacy events. | |
| * Returns the original release name, preserving null for repetitions of legacy events. |
There was a problem hiding this comment.
🟡 Changes recommended
The newest-release lookup uses an index that cannot satisfy the _id sort, forcing each new request to scan the project's full release history.
1 open issue
What changed in this PR
Exposes event release lifecycle metadata through GraphQL to support New, Resolved, and Regression labels while preserving the original event’s release when viewing repetitions.
Changes:
- Adds nullable lifecycle release fields and an
isNewflag based on the newest registered project release. - Normalizes release identifiers and preserves original metadata across event composition paths.
- Adds utility, factory, and GraphQL tests; bumps the package version to
1.7.0.
| File | Description |
|---|---|
test/utils/releaseName.test.ts |
Tests release identifier normalization. |
test/resolvers/event-release-metadata.test.ts |
Tests GraphQL metadata, isNew, and request caching. |
test/models/releasesFactory-is-new.test.ts |
Tests latest-release comparisons, caching, and Mongo query behavior. |
test/models/eventsFactory-release-metadata.test.ts |
Tests metadata preservation across originals and repetitions. |
src/utils/releaseName.ts |
Adds shared release-name normalization. |
src/typeDefs/event.ts |
Declares lifecycle release fields and isNew. |
src/resolvers/event.js |
Resolves normalized metadata and release-based newness. |
src/models/releasesFactory.ts |
Adds a cached latest-registered-release lookup. |
src/models/eventsFactory.js |
Preserves original lifecycle metadata during composition. |
package.json |
Bumps the version to 1.7.0. |
Review effort: Balanced
| projectId: 1, | ||
| release: 1, | ||
| }, | ||
| }).then(doc => normalizeReleaseName(doc?.release)); |
There was a problem hiding this comment.
findOne with sort: { _id: -1 } always takes the newest release document. normalizeReleaseName then turns '', whitespace, 'undefined', 'null', and 'NaN' into null, and name === null is false for every real event release. isNew stays false for the whole project until a newer document with a valid name is inserted.
Example: CI uploads release: process.env.VERSION while the variable is unset, so the newest document is stored as 'undefined'. Events first seen in the previous real release are not labeled New, and neither are events from that 'undefined' build.
Skip documents whose name normalizes to null and compare against the newest remaining one. The same { projectId, release } index still bounds the scan; stop at the first valid document instead of treating a placeholder as "there is no latest release". does not skip an invalid newest document currently locks this in.
const cursor = this.collection.find({ projectId: key }, {
sort: { _id: -1 },
projection: { release: 1, _id: 0 },
hint: { projectId: 1, release: 1 },
});
try {
for await (const doc of cursor) {
const latestName = normalizeReleaseName(doc.release);
if (latestName !== null) {
return latestName;
}
}
return null;
} finally {
await cursor.close();
}| this.latestRegisteredReleaseByProject.set(key, latest); | ||
| } | ||
|
|
||
| return name === await latest; |
There was a problem hiding this comment.
If findOne rejects, this await throws out of isNew. isNew is Boolean! and DailyEvent.event is Event!, so GraphQL drops the whole Event, then the whole DailyEvent. dailyEvents: [DailyEvent] keeps the list, but every row becomes null. The rejected promise is cached on the factory, so one Mongo failure does this to every event of the project in the request. The client gets a portion of nulls and the errors array, while the events are still in the database.
false is a valid Boolean!, so the row can stay on screen and the New badge stays off for this request. The factory is created per request, so the next request tries findOne again. Log the error; otherwise a Mongo failure looks like "nothing is new".
Catch the whole method, not only the await. findOne can throw synchronously (bad options, missing hinted index) before a promise exists, and that throw never reaches a catch wrapped around return name === await latest. The cache lookup stays inside the same try, so a cached rejection is handled too.
public async isNewInLatestRelease(
projectId: string | ObjectId | null | undefined,
firstSeenRelease: unknown
): Promise<boolean> {
try {
const name = normalizeReleaseName(firstSeenRelease);
if (!projectId || name === null) {
return false;
}
const key = projectId.toString();
let latest = this.latestRegisteredReleaseByProject.get(key);
if (!latest) {
latest = this.collection.findOne({ projectId: key }, {
sort: { _id: -1 },
projection: { release: 1, _id: 0 },
hint: { projectId: 1, release: 1 },
}).then(doc => normalizeReleaseName(doc?.release));
this.latestRegisteredReleaseByProject.set(key, latest);
}
return name === await latest;
} catch (error) {
console.error('Failed to resolve latest release', error);
return false;
}
}The integration test does not catch this: its release documents are tiny, findOne resolves, and isNew returns a boolean.
Add this to test/resolvers/event-release-metadata.test.ts. The hand-built schema has events: [Event!]!, so today the query comes back as data: null plus an error per row. After the catch it should come back as booleans and no errors:
it('keeps the event list when the latest-release query fails', async () => {
findOne.mockRejectedValue(new Error('mongo unavailable'));
const events = [
{ projectId, payload: { release: latestName } },
{ projectId, payload: { release: 'older' } },
];
const { data, errors } = await graphql({
schema,
source: '{ events { isNew } }',
rootValue: { events },
contextValue: context,
});
expect(errors).toBeUndefined();
expect(data?.events).toEqual([{ isNew: false }, { isNew: false }]);
expect(findOne).toHaveBeenCalledTimes(1);
});
Adding "Resolved", "New", "Regression" label to events. Related codex-team/hawk.workers#590
Summary
firstSeenInRelease,resolvedInRelease, andregressionInReleasefields to GraphQL Event.